import Tone from "../core/Tone";
import "../core/Emitter";
import "../core/Timeline";
import "../shim/AudioContext";

var AudioContextProperties = ["baseLatency", "destination", "currentTime", "sampleRate", "listener", "state"];
var AudioContextMethods = ["suspend", "close", "resume", "getOutputTimestamp", "createMediaElementSource", "createMediaStreamSource", "createMediaStreamDestination", "createBuffer", "decodeAudioData", "createBufferSource", "createConstantSource", "createGain", "createDelay", "createBiquadFilter", "createIIRFilter", "createWaveShaper", "createPanner", "createConvolver", "createDynamicsCompressor", "createAnalyser", "createScriptProcessor", "createStereoPanner", "createOscillator", "createPeriodicWave", "createChannelSplitter", "createChannelMerger", "audioWorklet"];

/**
 *  @class Wrapper around the native AudioContext.
 *  @extends {Tone.Emitter}
 *  @param {AudioContext=} context optionally pass in a context
 */
Tone.Context = function(){

	Tone.Emitter.call(this);

	var options = Tone.defaults(arguments, ["context"], Tone.Context);

	if (!options.context){
		options.context = new Tone.global.AudioContext();
		if (!options.context){
			throw new Error("could not create AudioContext. Possibly too many AudioContexts running already.");
		}
	}
	this._context = options.context;
	//make sure it's not an AudioContext wrapper
	while (this._context.rawContext){
		this._context = this._context.rawContext;
	}

	// extend all of the properties
	AudioContextProperties.forEach(function(prop){
		this._defineProperty(this._context, prop);
	}.bind(this));
	// extend all of the methods
	AudioContextMethods.forEach(function(method){
		this._defineMethod(this._context, method);
	}.bind(this));

	/**
	 *  The default latency hint
	 *  @type  {String}
	 *  @private
	 */
	this._latencyHint = options.latencyHint;

	/**
	 *  An object containing all of the constants AudioBufferSourceNodes
	 *  @type  {Object}
	 *  @private
	 */
	this._constants = {};

	///////////////////////////////////////////////////////////////////////
	// WORKER
	///////////////////////////////////////////////////////////////////////

	/**
	 *  The amount of time events are scheduled
	 *  into the future
	 *  @type  {Number}
	 */
	this.lookAhead = options.lookAhead;

	/**
	 *  A reference to the actual computed update interval
	 *  @type  {Number}
	 *  @private
	 */
	this._computedUpdateInterval = 0;

	/**
	 *  A reliable callback method
	 *  @private
	 *  @type  {Ticker}
	 */
	this._ticker = new Ticker(this.emit.bind(this, "tick"), options.clockSource, options.updateInterval);

	///////////////////////////////////////////////////////////////////////
	// TIMEOUTS
	///////////////////////////////////////////////////////////////////////

	/**
	 *  All of the setTimeout events.
	 *  @type  {Tone.Timeline}
	 *  @private
	 */
	this._timeouts = new Tone.Timeline();

	/**
	 *  The timeout id counter
	 *  @private
	 *  @type {Number}
	 */
	this._timeoutIds = 0;

	this.on("tick", this._timeoutLoop.bind(this));

	//forward state change events
	this._context.onstatechange = function(e){
		this.emit("statechange", e);
	}.bind(this);
};

Tone.extend(Tone.Context, Tone.Emitter);
Tone.Emitter.mixin(Tone.Context);

/**
 * defaults
 * @static
 * @type {Object}
 */
Tone.Context.defaults = {
	"clockSource" : "worker",
	"latencyHint" : "interactive",
	"lookAhead" : 0.1,
	"updateInterval" : 0.03
};

/**
 * Is an instanceof Tone.Context
 * @type {Boolean}
 */
Tone.Context.prototype.isContext = true;

/**
 *  Define a property on this Tone.Context.
 *  This is used to extend the native AudioContext
 *  @param  {AudioContext}  context
 *  @param  {String}  prop
 *  @private
 */
Tone.Context.prototype._defineProperty = function(context, prop){
	if (Tone.isUndef(this[prop])){
		Object.defineProperty(this, prop, {
			"get" : function(){
				return context[prop];
			},
			"set" : function(val){
				context[prop] = val;
			}
		});
	}
};

/**
 *  Define a method on this Tone.Context.
 *  This is used to extend the native AudioContext
 *  @param  {AudioContext}  context
 *  @param  {String}  prop
 *  @private
 */
Tone.Context.prototype._defineMethod = function(context, prop){
	if (Tone.isUndef(this[prop])){
		Object.defineProperty(this, prop, {
			"get" : function(){
				return context[prop].bind(context);
			}
		});
	}
};

/**
 *  The current audio context time
 *  @return  {Number}
 */
Tone.Context.prototype.now = function(){
	return this._context.currentTime + this.lookAhead;
};

/**
 *  The audio output destination. Alias for Tone.Master
 *  @readyOnly
 *  @type  {Tone.Master}
 */
Object.defineProperty(Tone.Context.prototype, "destination", {
	"get" : function(){
		if (!this.master){
			return this._context.destination;
		} else {
			return this.master;
		}
	}
});

/**
 *  Starts the audio context from a suspended state. This is required
 *  to initially start the AudioContext.
 *  @return  {Promise}
 */
Tone.Context.prototype.resume = function(){
	if (this._context.state === "suspended" && this._context instanceof AudioContext){
		return this._context.resume();
	} else {
		return Promise.resolve();
	}
};

/**
 *  Promise which is invoked when the context is running.
 *  Tries to resume the context if it's not started.
 *  @return  {Promise}
 */
Tone.Context.prototype.close = function(){
	var closePromise = Promise.resolve();
	//never close the global Tone.Context
	if (this !== Tone.global.TONE_AUDIO_CONTEXT){
		closePromise = this.rawContext.close();
	}
	return closePromise.then(function(){
		Tone.Context.emit("close", this);
	}.bind(this));
};

/**
 *  Generate a looped buffer at some constant value.
 *  @param  {Number}  val
 *  @return  {BufferSourceNode}
 */
Tone.Context.prototype.getConstant = function(val){
	if (this._constants[val]){
		return this._constants[val];
	} else {
		var buffer = this._context.createBuffer(1, 128, this._context.sampleRate);
		var arr = buffer.getChannelData(0);
		for (var i = 0; i < arr.length; i++){
			arr[i] = val;
		}
		var constant = this._context.createBufferSource();
		constant.channelCount = 1;
		constant.channelCountMode = "explicit";
		constant.buffer = buffer;
		constant.loop = true;
		constant.start(0);
		this._constants[val] = constant;
		return constant;
	}
};

/**
 *  The private loop which keeps track of the context scheduled timeouts
 *  Is invoked from the clock source
 *  @private
 */
Tone.Context.prototype._timeoutLoop = function(){
	var now = this.now();
	while (this._timeouts && this._timeouts.length && this._timeouts.peek().time <= now){
		this._timeouts.shift().callback();
	}
};

/**
 *  A setTimeout which is gaurenteed by the clock source.
 *  Also runs in the offline context.
 *  @param  {Function}  fn       The callback to invoke
 *  @param  {Seconds}    timeout  The timeout in seconds
 *  @returns {Number} ID to use when invoking Tone.Context.clearTimeout
 */
Tone.Context.prototype.setTimeout = function(fn, timeout){
	this._timeoutIds++;
	var now = this.now();
	this._timeouts.add({
		"callback" : fn,
		"time" : now + timeout,
		"id" : this._timeoutIds
	});
	return this._timeoutIds;
};

/**
 *  Clears a previously scheduled timeout with Tone.context.setTimeout
 *  @param  {Number}  id  The ID returned from setTimeout
 *  @return  {Tone.Context}  this
 */
Tone.Context.prototype.clearTimeout = function(id){
	this._timeouts.forEach(function(event){
		if (event.id === id){
			this.remove(event);
		}
	});
	return this;
};

/**
 *  How often the Web Worker callback is invoked.
 *  This number corresponds to how responsive the scheduling
 *  can be. Context.updateInterval + Context.lookAhead gives you the
 *  total latency between scheduling an event and hearing it.
 *  @type {Number}
 *  @memberOf Tone.Context#
 *  @name updateInterval
 */
Object.defineProperty(Tone.Context.prototype, "updateInterval", {
	"get" : function(){
		return this._ticker.updateInterval;
	},
	"set" : function(interval){
		this._ticker.updateInterval = interval;
	}
});

/**
 *  The unwrapped AudioContext.
 *  @type {AudioContext}
 *  @memberOf Tone.Context#
 *  @name rawContext
 *  @readOnly
 */
Object.defineProperty(Tone.Context.prototype, "rawContext", {
	"get" : function(){
		return this._context;
	}
});

/**
 *  What the source of the clock is, either "worker" (Web Worker [default]),
 *  "timeout" (setTimeout), or "offline" (none).
 *  @type {String}
 *  @memberOf Tone.Context#
 *  @name clockSource
 */
Object.defineProperty(Tone.Context.prototype, "clockSource", {
	"get" : function(){
		return this._ticker.type;
	},
	"set" : function(type){
		this._ticker.type = type;
	}
});

/**
 *  The type of playback, which affects tradeoffs between audio
 *  output latency and responsiveness.
 *
 *  In addition to setting the value in seconds, the latencyHint also
 *  accepts the strings "interactive" (prioritizes low latency),
 *  "playback" (prioritizes sustained playback), "balanced" (balances
 *  latency and performance), and "fastest" (lowest latency, might glitch more often).
 *  @type {String|Seconds}
 *  @memberOf Tone.Context#
 *  @name latencyHint
 *  @example
 * //set the lookAhead to 0.3 seconds
 * Tone.context.latencyHint = 0.3;
 */
Object.defineProperty(Tone.Context.prototype, "latencyHint", {
	"get" : function(){
		return this._latencyHint;
	},
	"set" : function(hint){
		var lookAhead = hint;
		this._latencyHint = hint;
		if (Tone.isString(hint)){
			switch (hint){
				case "interactive" :
					lookAhead = 0.1;
					this._context.latencyHint = hint;
					break;
				case "playback" :
					lookAhead = 0.8;
					this._context.latencyHint = hint;
					break;
				case "balanced" :
					lookAhead = 0.25;
					this._context.latencyHint = hint;
					break;
				case "fastest" :
					this._context.latencyHint = "interactive";
					lookAhead = 0.01;
					break;
			}
		}
		this.lookAhead = lookAhead;
		this.updateInterval = lookAhead/3;
	}
});

/**
 *  Unlike other dispose methods, this returns a Promise
 *  which executes when the context is closed and disposed
 *  @returns {Promise} this
 */
Tone.Context.prototype.dispose = function(){
	return this.close().then(function(){
		Tone.Emitter.prototype.dispose.call(this);
		this._ticker.dispose();
		this._ticker = null;
		this._timeouts.dispose();
		this._timeouts = null;
		for (var con in this._constants){
			this._constants[con].disconnect();
		}
		this._constants = null;
	}.bind(this));
};

/**
 * @class A class which provides a reliable callback using either
 *        a Web Worker, or if that isn't supported, falls back to setTimeout.
 * @private
 */
var Ticker = function(callback, type, updateInterval){

	/**
	 * Either "worker" or "timeout"
	 * @type {String}
	 * @private
	 */
	this._type = type;

	/**
	 * The update interval of the worker
	 * @private
	 * @type {Number}
	 */
	this._updateInterval = updateInterval;

	/**
	 * The callback to invoke at regular intervals
	 * @type {Function}
	 * @private
	 */
	this._callback = Tone.defaultArg(callback, Tone.noOp);

	//create the clock source for the first time
	this._createClock();
};

/**
 * The possible ticker types
 * @private
 * @type {Object}
 */
Ticker.Type = {
	"Worker" : "worker",
	"Timeout" : "timeout",
	"Offline" : "offline"
};

/**
 *  Generate a web worker
 *  @return  {WebWorker}
 *  @private
 */
Ticker.prototype._createWorker = function(){

	//URL Shim
	Tone.global.URL = Tone.global.URL || Tone.global.webkitURL;

	var blob = new Blob([
		//the initial timeout time
		"var timeoutTime = "+(this._updateInterval * 1000).toFixed(1)+";" +
		//onmessage callback
		"self.onmessage = function(msg){" +
		"	timeoutTime = parseInt(msg.data);" +
		"};" +
		//the tick function which posts a message
		//and schedules a new tick
		"function tick(){" +
		"	setTimeout(tick, timeoutTime);" +
		"	self.postMessage('tick');" +
		"}" +
		//call tick initially
		"tick();"
	]);
	var blobUrl = URL.createObjectURL(blob);
	var worker = new Worker(blobUrl);

	worker.onmessage = this._callback.bind(this);

	this._worker = worker;
};

/**
 * Create a timeout loop
 * @private
 */
Ticker.prototype._createTimeout = function(){
	this._timeout = setTimeout(function(){
		this._createTimeout();
		this._callback();
	}.bind(this), this._updateInterval * 1000);
};

/**
 * Create the clock source.
 * @private
 */
Ticker.prototype._createClock = function(){
	if (this._type === Ticker.Type.Worker){
		try {
			this._createWorker();
		} catch (e){
			// workers not supported, fallback to timeout
			this._type = Ticker.Type.Timeout;
			this._createClock();
		}
	} else if (this._type === Ticker.Type.Timeout){
		this._createTimeout();
	}
};

/**
 * @memberOf Ticker#
 * @type {Number}
 * @name updateInterval
 * @private
 */
Object.defineProperty(Ticker.prototype, "updateInterval", {
	"get" : function(){
		return this._updateInterval;
	},
	"set" : function(interval){
		this._updateInterval = Math.max(interval, 128/44100);
		if (this._type === Ticker.Type.Worker){
			this._worker.postMessage(Math.max(interval * 1000, 1));
		}
	}
});

/**
 * The type of the ticker, either a worker or a timeout
 * @memberOf Ticker#
 * @type {Number}
 * @name type
 * @private
 */
Object.defineProperty(Ticker.prototype, "type", {
	"get" : function(){
		return this._type;
	},
	"set" : function(type){
		this._disposeClock();
		this._type = type;
		this._createClock();
	}
});

/**
 * Clean up the current clock source
 * @private
 */
Ticker.prototype._disposeClock = function(){
	if (this._timeout){
		clearTimeout(this._timeout);
		this._timeout = null;
	}
	if (this._worker){
		this._worker.terminate();
		this._worker.onmessage = null;
		this._worker = null;
	}
};

/**
 * Clean up
 * @private
 */
Ticker.prototype.dispose = function(){
	this._disposeClock();
	this._callback = null;
};

// set the audio context initially, and if one is not already created
if (Tone.supported && !Tone.initialized){			
	if (!Tone.global.TONE_AUDIO_CONTEXT){
		Tone.global.TONE_AUDIO_CONTEXT = new Tone.Context();
	}
	Tone.context = Tone.global.TONE_AUDIO_CONTEXT;

	// log on first initialization
	// allow optional silencing of this log
	if (!Tone.global.TONE_SILENCE_LOGGING){
		var prefix = "v";
		if (Tone.version === "dev"){
			prefix = "";
		}
		var printString = " * Tone.js " + prefix + Tone.version + " * "; 
		// eslint-disable-next-line no-console
		console.log("%c" + printString, "background: #000; color: #fff");
	}
} else if (!Tone.supported && !Tone.global.TONE_SILENCE_LOGGING){
	// eslint-disable-next-line no-console
	console.warn("This browser does not support Tone.js");
}

export default Tone.Context;

